package bancosys.tec.persist.audit;

import java.io.Serializable;
import java.sql.Connection;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.Callable;

import jmine.tec.persist.PersistMessages;
import jmine.tec.utils.debug.TimerGroup;
import jmine.tec.utils.debug.impl.DebugRuntimeFactory;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.Criteria;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Restrictions;

import bancosys.tec.persist.Persistable;
import bancosys.tec.persist.audit.diff.AuditDiff;
import bancosys.tec.persist.audit.domain.AuditEntity;
import bancosys.tec.persist.audit.domain.AuditVersion;
import bancosys.tec.persist.authorization.DisplayNameUtil;
import bancosys.tec.persist.persister.PersistenceAction;

/**
 * @created 12/03/2008
 * @author Guilherme Nepomuceno
 */
public class AuditManager {

    private static final String AUDIT_IDENTIFIER = "audit";

    private static final String AUDIT_TIMER = "Audit Timer";

    /**
     * @return the {@link TimerGroup} to be used for audit readings
     */
    private static TimerGroup getAuditTimer() {
        return DebugRuntimeFactory.getInstance().getOrCreateTimer(AUDIT_TIMER);
    }

    /**
     * Armazena o status do authorization manager na thread atual.
     */
    private static final ThreadLocal<AuditStatus> THREAD_LOCAL_STATUS = new ThreadLocal<AuditStatus>();

    private static final ThreadLocal<String> AUDIT_SOURCE = new ThreadLocal<String>();

    /**
     * Lista de propriedades que devem ser ignoradas pela auditoria.
     */
    private Collection<String> ignoreList = new ArrayList<String>();

    private AuditLogManager auditLogManager;

    protected SessionFactory sessionFactory;

    private static final Log LOG = LogFactory.getLog(AuditManager.class);

    private AuditVersionController auditController;

    /**
     * Define o status da auditoria para a thread corrente.
     * 
     * @param auditStatusContext o status de auditoria para a thread corrente.
     */
    public void setThreadAuditStatus(AuditStatus auditStatusContext) {
        THREAD_LOCAL_STATUS.set(auditStatusContext);
    }

    /**
     * Retorna o status da auditoria para a thread corrente.
     * 
     * @return o status da auditoria para a thread corrente
     */
    public AuditStatus getThreadAuditStatus() {
        AuditStatus threadAuditStatus = THREAD_LOCAL_STATUS.get();
        if (threadAuditStatus == null) {
            return AuditStatus.ENABLED;
        } else {
            return threadAuditStatus;
        }
    }

    /**
     * Devolve a lista de propriedades a serem ignoradas.
     * 
     * @return a lista de propriedades a serem ignoradas.
     */
    protected Collection<String> getIgnoreList() {
        return this.ignoreList;
    }

    /**
     * Define o auditLogManager.
     * 
     * @param auditLogManager auditLogManager.
     */
    public void setAuditLogManager(AuditLogManager auditLogManager) {
        this.auditLogManager = auditLogManager;
    }

    /**
     * Define o session factory de hibernate.
     * 
     * @param sessionFactory o session factory de hibernate.
     */
    public void setSessionFactory(SessionFactory sessionFactory) {
        this.sessionFactory = sessionFactory;
    }

    /**
     * Define o controller de auditoria.
     * 
     * @param auditController controller de auditoria.
     */
    public void setAuditController(AuditVersionController auditController) {
        this.auditController = auditController;
    }

    /**
     * Faz a auditoria. Usa o método deprecated <code>Session.connection()</code> conscientemente pois o método foi "deprecated" mas ainda
     * não foi substituido.
     * 
     * @param bean o bean que será auditado.
     * @param id o id da entidade
     * @param action a ação de auditoria (Inserção, Alteração ou Remoção)
     */
    @SuppressWarnings("deprecation")
    public void audit(Persistable bean, PersistenceAction action, Serializable id) {
        if (this.isAuditable(bean)) {
            Auditable auditable = (Auditable) bean;
            Callable<AuditVersion> task = this.createCallable(bean, action, id);
            if (task == null) {
                LogFactory.getLog(this.getClass()).warn("cannot audit bean: " + bean.getClass() + "#" + id + " with action: " + action);
                return;
            }
            try {
                getAuditTimer().start(AUDIT_IDENTIFIER);
                AuditVersion version = this.auditController.executeTransacted(task);
                this.saveLog(this.sessionFactory.getCurrentSession().connection(), auditable, version, action, (Long) id);
            } catch (Exception e) {
                if (e instanceof RuntimeException) {
                    throw (RuntimeException) e;
                } else {
                    throw new IllegalStateException(e);
                }
            } finally {
                getAuditTimer().stop(AUDIT_IDENTIFIER);
            }
        }
    }

    /**
     * Cria a {@link Callable} com a tarefa de auditar.
     * 
     * @param bean o {@link Persistable}
     * @param action o {@link PersistenceAction}
     * @param id id
     * @return {@link Callable} de {@link AuditVersion}
     */
    private Callable<AuditVersion> createCallable(final Persistable bean, final PersistenceAction action, final Serializable id) {

        final String modificationSource = AuditManager.getAuditSource();

        if (action.equals(PersistenceAction.coldInsert) || action.equals(PersistenceAction.insert)
                || action.equals(PersistenceAction.insertWAction)) {
            return new Callable<AuditVersion>() {

                public AuditVersion call() throws Exception {
                    AuditDiff diff = AuditManager.this.createAuditDiff(action);
                    return diff.diff(null, (Auditable) bean, modificationSource);
                }
            };
        } else if (action.equals(PersistenceAction.update) || action.equals(PersistenceAction.coldUpdate)
                || action.equals(PersistenceAction.updateWAction)) {

            return new Callable<AuditVersion>() {

                public AuditVersion call() throws Exception {
                    AuditDiff diff = AuditManager.this.createAuditDiff(action);
                    return diff.diff(bean.getClass(), (Long) id, (Auditable) bean, modificationSource);
                }
            };
        } else if (action.equals(PersistenceAction.coldRemove) || action.equals(PersistenceAction.remove)) {

            return new Callable<AuditVersion>() {

                public AuditVersion call() throws Exception {
                    AuditDiff diff = AuditManager.this.createAuditDiff(action);
                    return diff.diff(bean.getClass(), (Long) id, ((Auditable) bean).getUser(), modificationSource);
                }
            };
        } else if (action.equals(PersistenceAction.reject)) {
            return new Callable<AuditVersion>() {

                public AuditVersion call() throws Exception {
                    AuditDiff diff = AuditManager.this.createAuditDiff(action);
                    return diff.diff((Auditable) bean, (Auditable) null, modificationSource);
                }
            };
        }
        return null;
    }

    /**
     * Cria o AuditDiff e popula a "ignore list".
     * 
     * @param action persistence action
     * @return AuditDiff inicializada.
     */
    protected AuditDiff createAuditDiff(PersistenceAction action) {
        AuditDiff diff = new AuditDiff(this.sessionFactory, this.auditController, action);
        diff.setIngnoreList(this.getIgnoreList());
        return diff;
    }

    /**
     * Salva os dados de auditoria.
     * 
     * @param conn a {@link Connection} com o banco
     * @param auditable dado que foi auditado.
     * @param version auditoria do dado.
     * @param action ação da persistência
     * @param id {@link Long} com o id da instancia que estaria na outra base
     */
    private void saveLog(Connection conn, Auditable auditable, AuditVersion version, PersistenceAction action, Long id) {
        Session session = this.sessionFactory.openSession(conn);
        try {
            AuditEntity entity;
            if (action.isCold()) {
                List<?> entities = this.findAudityEntityByClassAndId(auditable, id, session);

                switch (entities.size()) {
                case 0:
                    // inserindo a primeira versão de auditoria da entidade.
                    LOG.warn("Trilha de auditoria de inclusão não presente na base: " + auditable.getClass().getName() + "@"
                            + auditable.getId());
                    version.setStatus("Em autorização");
                    this.insertLog(session, auditable, version, id);
                    break;
                case 1:
                    // inserindo mais uma versão de auditoria da entidade.
                    entity = (AuditEntity) entities.get(0);
                    version.setStatus("Em autorização");
                    this.updateLog(session, entity, version);
                    break;
                default:
                    // base inconsistente, mais de uma trilha de auditoria encontrada para a entidade.
                    LOG.error("Encontrado mais de uma trilha de auditoria para a a mesma instância: " + auditable.getClass().getName()
                            + "@" + auditable.getId() + ". Base inconsistente.");
                    entity = (AuditEntity) entities.get(0);
                    this.updateLog(session, entity, version);
                    break;
                }
            } else if (action.equals(PersistenceAction.reject)) {
                List<?> entities = this.findAudityEntityByClassAndId(auditable, id, session);

                if (entities.size() >= 1) {
                    entity = (AuditEntity) entities.get(0);
                    if (!entity.getVersions().isEmpty()) {
                        AuditVersion auditVersion = entity.getVersions().get(entity.getVersions().size() - 1);
                        if (auditVersion.getAction().isCold()) {
                            auditVersion.setStatus("Não autorizado por " + this.auditController.getSecurityService().getCurrentUser()
                                    + " em " + new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(version.getTs()));
                            session.update(auditVersion);
                            session.flush();
                        }
                    }
                }
            } else {
                List<?> entities = this.findAudityEntityByClassAndId(auditable, id, session);
                this.atualizaStatusAutorizado(version);
                switch (entities.size()) {
                case 0:
                    this.insertLog(session, auditable, version, id);
                    break;
                default:
                    entity = (AuditEntity) entities.get(0);
                    this.updateLog(session, entity, version);
                    break;
                }
            }
        } catch (HibernateException e) {
            throw new AuditException(PersistMessages.ERROR_SAVING_AUDIT_LOG.create(), e);
        } finally {
            session.close();
        }
    }

    /**
     * Atualiza Status para autorizado
     * 
     * @param version {@link AuditVersion}
     */
    private void atualizaStatusAutorizado(AuditVersion version) {
        version.setStatus("Autorizado por " + this.auditController.getSecurityService().getCurrentUser() + " em "
                + new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(version.getTs()));
    }

    /**
     * Encontra os audit entity por classe e entityid
     * 
     * @param auditable {@link Auditable}
     * @param id {@link Long} o id a ser procurado
     * @param session {@link Session}
     * @return {@link List} de {@link AuditEntity}
     */
    private List<?> findAudityEntityByClassAndId(Auditable auditable, Long id, Session session) {
        Criteria criteria = session.createCriteria(AuditEntity.class);
        criteria.add(Restrictions.eq("className", auditable.getClass().getName()));
        criteria.add(Restrictions.eq("entityId", id));
        List<?> entities = criteria.list();
        return entities;
    }

    /**
     * Adiciona uma <code>version</code> ao log de uma entidade.
     * 
     * @param version <code>version</code> a ser adicionada
     * @param session sessão do hibernate.
     * @param entity log da entidade.
     */
    private void updateLog(Session session, AuditEntity entity, AuditVersion version) {
        version.setEntity(entity);
        session.save(version);
        session.flush();
    }

    /**
     * Cria um log para entidades que ainda não tem.
     * 
     * @param auditable entidade sendo auditada.
     * @param version versão de auditoria
     * @param session session do hibernate.
     * @param id o id da entidade
     */
    private void insertLog(Session session, Auditable auditable, AuditVersion version, Long id) {
        if (version.getAllProperties().size() > 0) {
            AuditEntity entity;
            entity = new AuditEntity();
            entity.setClassName(auditable.getClass().getName());
            if (version.getAction().equals(PersistenceAction.coldInsert)) {
                entity.setEntityId(auditable.getId());
            } else {
                entity.setEntityId(id);
            }
            entity.addVersion(version);
            entity.setName(DisplayNameUtil.getBeanDisplayName(auditable.getClass()));
            session.save(entity);
            session.flush();
        }
    }

    /**
     * Define o ignoreList, propriedades que não devem ser auditadas.
     * 
     * @param ignoreList O ignoreList a ser definido.
     */
    public void setIgnoreList(Collection<String> ignoreList) {
        this.ignoreList = ignoreList;
    }

    /**
     * Verifica se o bean é auditavel.
     * 
     * @param bean bean
     * @return <code>true</code> se o bean é auditavel, <code>false</code> caso contrário
     */
    private boolean isAuditable(Persistable bean) {
        if (bean instanceof Auditable && AuditStatus.ENABLED.equals(this.getThreadAuditStatus())) {
            return this.auditLogManager.isAuditable((Auditable) bean);
        }
        return false;
    }

    /**
     * Seta o modification source para a thread atual
     * 
     * @param auditSource String
     */
    public static void setAuditSource(String auditSource) {
        if (auditSource == null) {
            AUDIT_SOURCE.remove();
        } else {
            AUDIT_SOURCE.set(auditSource);
        }
    }

    /**
     * @return String the audit modification source
     */
    public static String getAuditSource() {
        String modificationSource = AUDIT_SOURCE.get();
        return modificationSource == null ? "unknown" : modificationSource;
    }

}
